// preview controller
(() => {
  interface MarkdownConfig {
    usePandocParser?: boolean;
    scrollSync?: boolean;
    mathRenderingOption?: string;
    imageFolderPath?: string;
    imageUploader?: string;
    enableScriptExecution?: boolean;
    /**
     * Whether this preview is for vscode or not.
     */
    vscode?: boolean;
    zoomLevel?: number;
  }

  /**
   * .mpe-toolbar {
   *   .refresh-btn
   *   .back-to-top-btn
   *   .sidebar-toc-btn
   * }
   */
  interface Toolbar {
    toolbar: HTMLElement;
    backToTopBtn: HTMLElement;
    refreshBtn: HTMLElement;
    sidebarTOCBtn: HTMLElement;
  }

  let $ = null;

  class PreviewController {
    /**
     * VSCode API object got from acquireVsCodeApi
     */
    private vscodeAPI = null;

    /**
     * Whether finished loading preview
     */
    private doneLoadingPreview: boolean = false;

    /**
     * This is the element with class `mume`
     * The final html is rendered by that previewElement
     */
    private previewElement: HTMLElement;

    /**
     * .mume.hidden-preview element
     * HiddenPreviewElement is used to render html and then put the rendered html result to previewElement
     */
    private hiddenPreviewElement: HTMLElement;

    /**
     * Toolbar object
     */
    private toolbar: Toolbar;

    /**
     * Whether to enable sidebar toc
     */
    private enableSidebarTOC: boolean = false;

    /**
     * .sidebar-toc element
     */
    private sidebarTOC: HTMLElement = null;

    /**
     * .sidebar-toc element innerHTML generated by markdown-engine.ts
     */
    private sidebarTOCHTML: string = "";

    /**
     * .refreshing-icon element
     */
    private refreshingIcon: HTMLElement;
    private refreshingIconTimeout = null;

    /**
     * Scroll map that maps buffer line to scrollTops of html elements
     */
    private scrollMap: number[] = null;

    /**
     * TextEditor total buffer line count
     */
    private totalLineCount: number = 0;

    /**
     * TextEditor cursor current line position
     */
    private currentLine: number = -1;

    /**
     * TextEditor inital line position
     */
    private initialLine: number = 0;

    /**
     * Used to delay preview scroll
     */
    private previewScrollDelay: number = 0;

    /**
     * Whether enter presentation mode
     */

    private presentationMode: boolean;

    /**
     * Track the slide line number, and (h, v) indices
     */
    private slidesData: {
      line: number;
      h: number;
      v: number;
      offset: number;
    }[] = [];

    /**
     * Current slide offset
     */
    private currentSlideOffset: number = -1;

    /**
     * SetTimeout value
     */
    private scrollTimeout: any = null;

    /**
     * Configs
     */
    private config: MarkdownConfig = {};

    /**
     * Markdown file URI
     */
    private sourceUri = null;

    /**
     * Caches
     */
    private zenumlCache = {};
    private wavedromCache = {};
    private flowchartCache = {};
    private sequenceDiagramCache = {};

    /**
     * This controller should be initialized when the html dom is loaded.
     */
    constructor() {
      $ = window["$"] as JQuery;

      /** init preview elements */
      const previewElement = document.getElementsByClassName(
        "mume",
      )[0] as HTMLElement;
      const hiddenPreviewElement = document.createElement("div");
      hiddenPreviewElement.classList.add("mume");
      hiddenPreviewElement.classList.add("markdown-preview");
      hiddenPreviewElement.classList.add("hidden-preview");
      hiddenPreviewElement.setAttribute("for", "preview");
      hiddenPreviewElement.style.zIndex = "0";
      previewElement.insertAdjacentElement("beforebegin", hiddenPreviewElement);

      /** init `window` events */
      this.initWindowEvents();

      /** init contextmenu */
      this.initContextMenu();

      /** load config */
      this.config = JSON.parse(
        document.getElementById("mume-data").getAttribute("data-config"),
      );
      this.sourceUri = this.config["sourceUri"];

      /*
    if (this.config.vscode) { // remove vscode default styles
      const defaultStyles = document.getElementById('_defaultStyles')
      if (defaultStyles) defaultStyles.remove()
    }
    */

      // console.log("init webview: " + this.sourceUri);

      // console.log(document.getElementsByTagName('html')[0].innerHTML)
      // console.log(JSON.stringify(config))

      /** init preview properties */
      (this.previewElement = previewElement),
        (this.hiddenPreviewElement = hiddenPreviewElement),
        (this.currentLine = this.config["line"] || -1);
      this.initialLine = this.config["initialLine"] || 0;
      this.presentationMode = previewElement.hasAttribute(
        "data-presentation-mode",
      );
      (this.toolbar = {
        toolbar: document.getElementById("md-toolbar") as HTMLElement,
        backToTopBtn: document.getElementsByClassName(
          "back-to-top-btn",
        )[0] as HTMLElement,
        refreshBtn: document.getElementsByClassName(
          "refresh-btn",
        )[0] as HTMLElement,
        sidebarTOCBtn: document.getElementsByClassName(
          "sidebar-toc-btn",
        )[0] as HTMLElement,
      }),
        (this.refreshingIcon = document.getElementsByClassName(
          "refreshing-icon",
        )[0] as HTMLElement),
        /** init toolbar event */
        this.initToolbarEvent();

      /** init image helper */
      this.initImageHelper();

      /** set zoom */
      this.setZoomLevel();

      /**
       * If it's not presentation mode, then we need to tell the parent window
       * that the preview is loaded, and the markdown needs to be updated so that
       * we can update properties like `sidebarTOCHTML`, etc...
       */
      if (!this.presentationMode) {
        previewElement.onscroll = this.scrollEvent.bind(this);

        this.postMessage("webviewFinishLoading", [this.sourceUri]);
      } else {
        // TODO: presentation preview to source sync
        this.config.scrollSync = true; // <= force to enable scrollSync for presentation
        this.initPresentationEvent();
      }

      // make it possible for interactive vega to load local data files
      const base = document.createElement("base");
      base.href = this.sourceUri;
      document.head.appendChild(base);

      // console.log(document.getElementsByTagName('html')[0].outerHTML)
    }

    /**
     * Post message to parent window
     * @param command
     * @param args
     */
    private postMessage(command: string, args: any[] = []) {
      if (this.config.vscode) {
        if (!this.vscodeAPI) {
          // @ts-ignore
          this.vscodeAPI = acquireVsCodeApi();
        }
        // post message to vscode
        this.vscodeAPI.postMessage({
          command,
          args,
        });
      } else {
        window.parent.postMessage(
          {
            command,
            args,
          },
          "file://",
        );
      }
    }

    /**
     * init events for tool bar
     */
    private initToolbarEvent() {
      const toolbarElement = this.toolbar.toolbar;
      const showToolbar = () => (toolbarElement.style.opacity = "1");
      this.previewElement.onmouseenter = showToolbar;
      this.toolbar.toolbar.onmouseenter = showToolbar;
      this.previewElement.onmouseleave = () =>
        (toolbarElement.style.opacity = "0");

      this.initSideBarTOCButton();
      this.initBackToTopButton();
      this.initRefreshButton();

      return toolbar;
    }

    /**
     * init .sidebar-toc-btn
     */
    private initSideBarTOCButton() {
      this.toolbar.sidebarTOCBtn.onclick = () => {
        if (this.presentationMode) {
          return window["Reveal"].toggleOverview();
        }

        this.enableSidebarTOC = !this.enableSidebarTOC;

        if (this.enableSidebarTOC) {
          this.sidebarTOC = document.createElement("div"); // create new sidebar toc
          this.sidebarTOC.classList.add("md-sidebar-toc");
          document.body.appendChild(this.sidebarTOC);
          document.body.classList.add("show-sidebar-toc");
          this.renderSidebarTOC();
          this.bindTagAClickEvent();
          this.setZoomLevel();
        } else {
          if (this.sidebarTOC) {
            this.sidebarTOC.remove();
          }
          this.sidebarTOC = null;
          document.body.classList.remove("show-sidebar-toc");
          this.previewElement.style.width = "100%";
        }

        this.scrollMap = null;
      };
    }

    /**
     * init .back-to-top-btn
     */
    private initBackToTopButton() {
      this.toolbar.backToTopBtn.onclick = () => {
        if (this.presentationMode) {
          return window["Reveal"].slide(0);
        }

        this.previewElement.scrollTop = 0;
      };
    }

    /**
     * init .refresh-btn
     */
    private initRefreshButton() {
      this.toolbar.refreshBtn.onclick = () => {
        this.postMessage("refreshPreview", [this.sourceUri]);
      };
    }

    /**
     * init contextmenu
     * reference: http://jsfiddle.net/w33z4bo0/1/
     */
    private initContextMenu() {
      $["contextMenu"]({
        selector: ".preview-container",
        items: {
          open_in_browser: {
            name: "Open in Browser",
            callback: () => this.postMessage("openInBrowser", [this.sourceUri]),
          },
          sep1: "---------",
          html_export: {
            name: "HTML",
            items: {
              html_offline: {
                name: "HTML (offline)",
                callback: () =>
                  this.postMessage("htmlExport", [this.sourceUri, true]),
              },
              html_cdn: {
                name: "HTML (cdn hosted)",
                callback: () =>
                  this.postMessage("htmlExport", [this.sourceUri, false]),
              },
            },
          },
          chrome_export: {
            name: "Chrome (Puppeteer)",
            items: {
              chrome_pdf: {
                name: "PDF",
                callback: () =>
                  this.postMessage("chromeExport", [this.sourceUri, "pdf"]),
              },
              chrome_png: {
                name: "PNG",
                callback: () =>
                  this.postMessage("chromeExport", [this.sourceUri, "png"]),
              },
              chrome_jpeg: {
                name: "JPEG",
                callback: () =>
                  this.postMessage("chromeExport", [this.sourceUri, "jpeg"]),
              },
            },
          },
          prince_export: {
            name: "PDF (prince)",
            callback: () => this.postMessage("princeExport", [this.sourceUri]),
          },
          ebook_export: {
            name: "eBook",
            items: {
              ebook_epub: {
                name: "ePub",
                callback: () =>
                  this.postMessage("eBookExport", [this.sourceUri, "epub"]),
              },
              ebook_mobi: {
                name: "mobi",
                callback: () =>
                  this.postMessage("eBookExport", [this.sourceUri, "mobi"]),
              },
              ebook_pdf: {
                name: "PDF",
                callback: () =>
                  this.postMessage("eBookExport", [this.sourceUri, "pdf"]),
              },
              ebook_html: {
                name: "HTML",
                callback: () =>
                  this.postMessage("eBookExport", [this.sourceUri, "html"]),
              },
            },
          },
          pandoc_export: {
            name: "Pandoc",
            callback: () => this.postMessage("pandocExport", [this.sourceUri]),
          },
          save_as_markdown: {
            name: "Save as Markdown",
            callback: () =>
              this.postMessage("markdownExport", [this.sourceUri]),
          },
          sep2: "---------",
          image_helper: {
            name: "Image Helper",
            callback: () => window["$"]("#image-helper-view").modal(),
          },
          sep3: "---------",
          sync_source: {
            name: "Sync Source",
            callback: () => this.previewSyncSource(),
          },
        },
      });
    }

    /**
     * init image helper
     */
    private initImageHelper() {
      const imageHelper = document.getElementById("image-helper-view");

      // url editor
      // used to insert image url
      const urlEditor = imageHelper.getElementsByClassName(
        "url-editor",
      )[0] as HTMLInputElement;
      urlEditor.addEventListener("keypress", (event: KeyboardEvent) => {
        if (event.keyCode === 13) {
          // enter key pressed
          let url = urlEditor.value.trim();
          if (url.indexOf(" ") >= 0) {
            url = `<${url}>`;
          }
          if (url.length) {
            $["modal"].close(); // close modal
            this.postMessage("insertImageUrl", [this.sourceUri, url]);
          }
          return false;
        } else {
          return true;
        }
      });

      const copyLabel = imageHelper.getElementsByClassName(
        "copy-label",
      )[0] as HTMLLabelElement;
      copyLabel.innerText = `Copy image to ${
        this.config.imageFolderPath[0] === "/" ? "workspace" : "relative"
      } ${this.config.imageFolderPath} folder`;

      const imageUploaderSelect = imageHelper.getElementsByClassName(
        "uploader-select",
      )[0] as HTMLSelectElement;
      imageUploaderSelect.value = this.config.imageUploader;

      // drop area has 2 events:
      // 1. paste(copy) image to imageFolderPath
      // 2. upload image
      const dropArea = window["$"](".drop-area", imageHelper);
      const fileUploader = window["$"](".file-uploader", imageHelper);
      dropArea.on(
        "drop dragend dragstart dragenter dragleave drag dragover",
        (e) => {
          e.preventDefault();
          e.stopPropagation();
          if (e.type === "drop") {
            if (e.target.className.indexOf("paster") >= 0) {
              // paste
              const files = e.originalEvent.dataTransfer.files;
              for (const file of files) {
                this.postMessage("pasteImageFile", [this.sourceUri, file.path]);
              }
            } else {
              // upload
              const files = e.originalEvent.dataTransfer.files;
              for (const file of files) {
                this.postMessage("uploadImageFile", [
                  this.sourceUri,
                  file.path,
                  imageUploaderSelect.value,
                ]);
              }
            }
            $["modal"].close(); // close modal
          }
        },
      );
      dropArea.on("click", function(e) {
        e.preventDefault();
        e.stopPropagation();
        window["$"](this)
          .find('input[type="file"]')
          .click();
        $["modal"].close(); // close modal
      });
      fileUploader.on("click", (e) => {
        e.stopPropagation();
      });
      fileUploader.on("change", (e) => {
        if (e.target.className.indexOf("paster") >= 0) {
          // paste
          const files = e.target.files;
          for (const file of files) {
            this.postMessage("pasteImageFile", [this.sourceUri, file.path]);
          }
          fileUploader.val("");
        } else {
          // upload
          const files = e.target.files;
          for (const file of files) {
            this.postMessage("uploadImageFile", [
              this.sourceUri,
              file.path,
              imageUploaderSelect.value,
            ]);
          }
          fileUploader.val("");
        }
      });

      // show image uploaded history
      const a = imageHelper.querySelector(
        "#show-uploaded-image-history",
      ) as HTMLAnchorElement;
      a.onclick = (event) => {
        event.preventDefault();
        event.stopPropagation();
        $["modal"].close();
        this.postMessage("showUploadedImageHistory", [this.sourceUri]);
      };
    }

    /**
     * Init several events for presentation mode
     */
    private initPresentationEvent() {
      let initialSlide = null;
      window["Reveal"].addEventListener("ready", (event) => {
        if (initialSlide) {
          initialSlide.style.visibility = "visible";
        }

        // several events...
        this.setupCodeChunks();
        this.bindTagAClickEvent();
        this.bindTaskListEvent();

        // scroll slides
        window["Reveal"].addEventListener("slidechanged", (event2) => {
          if (Date.now() < this.previewScrollDelay) {
            return;
          }

          const { indexh, indexv } = event2;
          for (const slideData of this.slidesData) {
            const { h, v, line } = slideData;
            if (h === indexh && v === indexv) {
              this.postMessage("revealLine", [this.sourceUri, line + 6]);
            }
          }
        });
      });

      // analyze slides
      this.initSlidesData();

      // slide to initial position
      window["Reveal"].configure({ transition: "none" });
      this.scrollToRevealSourceLine(this.initialLine);
      window["Reveal"].configure({ transition: "slide" });

      initialSlide = window["Reveal"].getCurrentSlide();
      if (initialSlide) {
        initialSlide.style.visibility = "hidden";
      }
    }

    // zoom in preview
    private zoomIn() {
      this.config.zoomLevel = (this.config.zoomLevel || 1) + 0.1;
      this.setZoomLevel();
    }

    // zoom out preview
    private zoomOut() {
      this.config.zoomLevel = (this.config.zoomLevel || 1) - 0.1;
      this.setZoomLevel();
    }

    // reset preview zoom
    private resetZoom() {
      this.config.zoomLevel = 1;
      this.setZoomLevel();
    }

    private setZoomLevel() {
      const zoomLevel = this.config.zoomLevel || 1;
      this.previewElement.style.zoom = zoomLevel.toString();
      if (this.enableSidebarTOC) {
        this.previewElement.style.width = `calc(100% - ${268 / zoomLevel}px)`;
      }
      this.scrollMap = null;

      if (!this.config.vscode) {
        this.postMessage("setZoomLevel", [this.sourceUri, zoomLevel]);
      }
    }

    /**
     * render mermaid graphs
     */
    private renderMermaid() {
      return new Promise((resolve, reject) => {
        const mermaid = window["mermaid"]; // window.mermaid doesn't work, has to be written as window['mermaid']
        const mermaidGraphs = this.previewElement.getElementsByClassName(
          "mermaid",
        );

        const validMermaidGraphs = [];
        for (let i = 0; i < mermaidGraphs.length; i++) {
          const mermaidGraph = mermaidGraphs[i] as HTMLElement;
          try {
            mermaid.parse(mermaidGraph.textContent.trim());
            validMermaidGraphs.push(mermaidGraph);
          } catch (error) {
            mermaidGraph.innerHTML = `<pre class="language-text">${error.str.toString()}</pre>`;
          }
        }

        if (!validMermaidGraphs.length) {
          return resolve();
        } else {
          validMermaidGraphs.forEach((mermaidGraph, offset) => {
            const svgId = "svg-mermaid-" + Date.now() + "-" + offset;
            const code = mermaidGraph.textContent.trim();
            try {
              mermaid.render(svgId, code, (svgCode) => {
                mermaidGraph.innerHTML = svgCode;
              });
            } catch (error) {
              const noiseElement = document.getElementById("d" + svgId);
              if (noiseElement) {
                noiseElement.style.display = "none";
              }
              mermaidGraph.innerHTML = `<pre class="language-text">${error.toString()}</pre>`;
            }
          });
          return resolve();
        }
      });
    }

    /**
     * render interactive vega and vega lite
     * This function is copied from render flowchart
     */
    private renderInteractiveVega() {
      return new Promise((resolve, reject) => {
        const vegaElements = this.previewElement.querySelectorAll(
          ".vega, .vega-lite",
        );
        function reportVegaError(el, error) {
          el.innerHTML =
            '<pre class="language-text">' + error.toString() + "</pre>";
        }
        for (let i = 0; i < vegaElements.length; i++) {
          const vegaElement = vegaElements[i];
          try {
            const spec = JSON.parse(vegaElement.textContent.trim());
            window["vegaEmbed"](vegaElement, spec, { actions: false }).catch(
              (error) => {
                reportVegaError(vegaElement, error);
              },
            );
          } catch (error) {
            reportVegaError(vegaElement, error);
          }
        }
        resolve();
      });
    }

    /**
     * render flowchart
     * This function doesn't work with `hiddenPreviewElement`
     */
    private renderFlowchart() {
      return new Promise((resolve, reject) => {
        const flowcharts = this.previewElement.getElementsByClassName("flow");
        const newFlowchartCache = {};
        for (let i = 0; i < flowcharts.length; i++) {
          const flow = flowcharts[i];
          const text = flow.textContent.trim();
          if (text in this.flowchartCache) {
            flow.innerHTML = this.flowchartCache[text];
          } else {
            try {
              const diagram = window["flowchart"].parse(text);
              flow.innerHTML = "";
              diagram.drawSVG(flow);
            } catch (error) {
              flow.innerHTML =
                '<pre class="language-text">' + error.toString() + "</pre>";
            }
          }
          newFlowchartCache[text] = flow.innerHTML;
        }
        this.flowchartCache = newFlowchartCache;
        resolve();
      });
    }

    /**
     * render sequence diagram
     */
    private renderSequenceDiagram() {
      return new Promise((resolve, reject) => {
        const sequenceDiagrams = this.previewElement.getElementsByClassName(
          "sequence",
        );
        const newSequenceDiagramCache = {};
        for (let i = 0; i < sequenceDiagrams.length; i++) {
          const sequence = sequenceDiagrams[i] as HTMLElement;
          const text = sequence.textContent.trim();
          const theme = sequence.getAttribute("theme") || "simple";
          const cacheKey = text + "$" + theme;
          if (cacheKey in this.sequenceDiagramCache) {
            sequence.innerHTML = this.sequenceDiagramCache[cacheKey];
          } else {
            try {
              const diagram = window["Diagram"].parse(text);
              sequence.innerHTML = "";
              diagram.drawSVG(sequence, { theme });
            } catch (error) {
              sequence.innerHTML =
                '<pre class="language-text">' + error.toString() + "</pre>";
            }
          }
          newSequenceDiagramCache[cacheKey] = sequence.innerHTML;
        }
        this.sequenceDiagramCache = newSequenceDiagramCache;
        resolve();
      });
    }

    /**
     * render wavedrom
     */
    private async renderWavedrom() {
      const els = this.hiddenPreviewElement.getElementsByClassName("wavedrom");
      if (els.length) {
        const wavedromCache = {};
        for (let i = 0; i < els.length; i++) {
          const el = els[i] as HTMLElement;
          el.id = "wavedrom" + i;
          const text = el.textContent.trim();
          if (!text.length) {
            continue;
          }

          if (text in this.wavedromCache) {
            // load cache
            const svg = this.wavedromCache[text];
            el.innerHTML = svg;
            wavedromCache[text] = svg;
            continue;
          }

          try {
            const content = eval(`(${text})`);
            window["WaveDrom"].RenderWaveForm(i, content, "wavedrom");
            wavedromCache[text] = el.innerHTML;
          } catch (error) {
            el.innerText = "Failed to eval WaveDrom code. " + error;
          }
        }

        this.wavedromCache = wavedromCache;
      }
    }

    /**UML
     * render zenuml
     */
    private async renderZenUML() {
      const els = this.hiddenPreviewElement.getElementsByClassName("zenuml");
      if (els.length) {
        const zenumlCache = {};
        for (let i = 0; i < els.length; i++) {
          const el = els[i] as HTMLElement;
          el.id = "zenuml" + i;
          const text = el.textContent.trim();
          if (!text.length) {
            continue;
          }

          if (text in this.zenumlCache) {
            // load cache
            const svg = this.zenumlCache[text];
            el.innerHTML = svg;
            zenumlCache[text] = svg;
            continue;
          }

          try {
            const content = `<sequence-diagram>${text}</sequence-diagram>`;
            // window["WaveDrom"].RenderWaveForm(i, content, "wavedrom");
            el.innerHTML = content;
            zenumlCache[text] = el.innerHTML;
          } catch (error) {
            el.innerText = "Failed to eval ZenUML code. " + error;
          }
        }

        this.zenumlCache = zenumlCache;
      }
    }

    /**
     * render MathJax expressions
     */
    private renderMathJax() {
      return new Promise((resolve, reject) => {
        if (
          this.config.mathRenderingOption === "MathJax" ||
          this.config.usePandocParser
        ) {
          const MathJax = window["MathJax"];
          // .mathjax-exps, .math.inline, .math.display
          const unprocessedElements = this.hiddenPreviewElement.querySelectorAll(
            ".mathjax-exps, .math.inline, .math.display",
          );
          if (!unprocessedElements.length) {
            return resolve();
          }

          window["MathJax"].Hub.Queue(
            ["Typeset", MathJax.Hub, this.hiddenPreviewElement],
            [
              () => {
                // sometimes the this callback will be called twice
                // and only the second time will the Math expressions be rendered.
                // therefore, I added the line below to check whether math is already rendered.
                if (
                  !this.hiddenPreviewElement.getElementsByClassName("MathJax")
                    .length
                ) {
                  return;
                }

                this.scrollMap = null;
                return resolve();
              },
            ],
          );
        } else {
          return resolve();
        }
      });
    }

    /**
     * Run code chunk with 'id'
     * @param id
     */
    private runCodeChunk(id: string) {
      if (!this.config.enableScriptExecution) {
        return;
      }

      const codeChunk = document.querySelector(`.code-chunk[data-id="${id}"]`);
      const running = codeChunk.classList.contains("running");
      if (running) {
        return;
      }
      codeChunk.classList.add("running");

      if (codeChunk.getAttribute("data-cmd") === "javascript") {
        // javascript code chunk
        const code = codeChunk.getAttribute("data-code");
        try {
          eval(`((function(){${code}$})())`);
          codeChunk.classList.remove("running"); // done running javascript code

          const CryptoJS = window["CryptoJS"];
          const result = CryptoJS.AES.encrypt(
            codeChunk.getElementsByClassName("output-div")[0].outerHTML,
            "mume",
          ).toString();

          this.postMessage("cacheCodeChunkResult", [
            this.sourceUri,
            id,
            result,
          ]);
        } catch (e) {
          const outputDiv = codeChunk.getElementsByClassName("output-div")[0];
          outputDiv.innerHTML = `<pre>${e.toString()}</pre>`;
        }
      } else {
        this.postMessage("runCodeChunk", [this.sourceUri, id]);
      }
    }

    /**
     * Run all code chunks
     */
    private runAllCodeChunks() {
      if (!this.config.enableScriptExecution) {
        return;
      }

      const codeChunks = this.previewElement.getElementsByClassName(
        "code-chunk",
      );
      for (let i = 0; i < codeChunks.length; i++) {
        codeChunks[i].classList.add("running");
      }

      this.postMessage("runAllCodeChunks", [this.sourceUri]);
    }

    /**
     * Run the code chunk that is the nearest to this.currentLine
     */
    private runNearestCodeChunk() {
      const currentLine = this.currentLine;
      const elements = this.previewElement.children;
      for (let i = elements.length - 1; i >= 0; i--) {
        if (
          elements[i].classList.contains("sync-line") &&
          elements[i + 1] &&
          elements[i + 1].classList.contains("code-chunk")
        ) {
          if (
            currentLine >= parseInt(elements[i].getAttribute("data-line"), 10)
          ) {
            const codeChunkId = elements[i + 1].getAttribute("data-id");
            return this.runCodeChunk(codeChunkId);
          }
        }
      }
    }

    /**
     * Setup code chunks
     */
    private setupCodeChunks() {
      const codeChunks = this.previewElement.getElementsByClassName(
        "code-chunk",
      );
      if (!codeChunks.length) {
        return;
      }

      for (let i = 0; i < codeChunks.length; i++) {
        const codeChunk = codeChunks[i];
        const id = codeChunk.getAttribute("data-id");

        // bind click event
        const runBtn = codeChunk.getElementsByClassName("run-btn")[0];
        const runAllBtn = codeChunk.getElementsByClassName("run-all-btn")[0];
        if (runBtn) {
          runBtn.addEventListener("click", () => {
            this.runCodeChunk(id);
          });
        }
        if (runAllBtn) {
          runAllBtn.addEventListener("click", () => {
            this.runAllCodeChunks();
          });
        }
      }
    }

    /**
     * render sidebar toc
     */
    private renderSidebarTOC() {
      if (!this.enableSidebarTOC) {
        return;
      }
      if (this.sidebarTOCHTML) {
        this.sidebarTOC.innerHTML = this.sidebarTOCHTML;
      } else {
        this.sidebarTOC.innerHTML = `<p style="text-align:center;font-style: italic;">Outline (empty)</p>`;
      }
    }

    /**
     * init several preview events
     */
    private async initEvents() {
      await Promise.all([
        this.renderMathJax(),
        this.renderZenUML(),
        this.renderWavedrom(),
      ]);
      this.previewElement.innerHTML = this.hiddenPreviewElement.innerHTML;
      this.hiddenPreviewElement.innerHTML = "";

      await Promise.all([
        this.renderFlowchart(),
        this.renderInteractiveVega(),
        this.renderSequenceDiagram(),
        this.renderMermaid(),
      ]);

      this.setupCodeChunks();

      if (this.refreshingIconTimeout) {
        clearTimeout(this.refreshingIconTimeout);
        this.refreshingIconTimeout = null;
      }
      this.refreshingIcon.style.display = "none";
    }

    /**
     * Bind <a href="..."></a> click events.
     */
    private bindTagAClickEvent() {
      const helper = (as) => {
        for (let i = 0; i < as.length; i++) {
          const a = as[i];
          const href = decodeURIComponent(a.getAttribute("href")); // decodeURI here for Chinese like unicode heading
          if (href && href[0] === "#") {
            const targetElement = this.previewElement.querySelector(
              `[id=\"${encodeURIComponent(href.slice(1))}\"]`,
            ) as HTMLElement; // fix number id bug
            if (targetElement) {
              a.onclick = (event) => {
                event.preventDefault();
                event.stopPropagation();

                // jump to tag position
                let offsetTop = 0;
                let el = targetElement;
                while (el && el !== this.previewElement) {
                  offsetTop += el.offsetTop;
                  el = el.offsetParent as HTMLElement;
                }

                if (this.previewElement.scrollTop > offsetTop) {
                  this.previewElement.scrollTop =
                    offsetTop - 32 - targetElement.offsetHeight;
                } else {
                  this.previewElement.scrollTop = offsetTop;
                }
              };
            } else {
              // without the `else` here, mpe package on Atom will fail to render preview (issue #824 and #827).
              a.onclick = (event) => {
                event.preventDefault();
                event.stopPropagation();
              };
            }
          } else {
            a.onclick = (event) => {
              event.preventDefault();
              event.stopPropagation();
              this.postMessage("clickTagA", [
                this.sourceUri,
                encodeURIComponent(href.replace(/\\/g, "/")),
              ]);
            };
          }
        }
      };
      helper(this.previewElement.getElementsByTagName("a"));

      if (this.sidebarTOC) {
        helper(this.sidebarTOC.getElementsByTagName("a"));
      }
    }

    /**
     * Initialize Tast list items checkbox click event.
     */
    private bindTaskListEvent() {
      const taskListItemCheckboxes = this.previewElement.getElementsByClassName(
        "task-list-item-checkbox",
      );
      for (let i = 0; i < taskListItemCheckboxes.length; i++) {
        const checkbox = taskListItemCheckboxes[i] as HTMLInputElement;
        let li = checkbox.parentElement;
        if (li.tagName !== "LI") {
          li = li.parentElement;
        }
        if (li.tagName === "LI") {
          li.classList.add("task-list-item");

          // bind checkbox click event
          checkbox.onclick = (event) => {
            event.preventDefault();

            const checked = checkbox.checked;
            if (checked) {
              checkbox.setAttribute("checked", "");
            } else {
              checkbox.removeAttribute("checked");
            }

            const dataLine = parseInt(checkbox.getAttribute("data-line"), 10);
            if (!isNaN(dataLine)) {
              this.postMessage("clickTaskListCheckbox", [
                this.sourceUri,
                dataLine,
              ]);
            }
          };
        }
      }
    }

    /**
     * update previewElement innerHTML content
     * @param html
     */
    private updateHTML(html: string, id: string, classes: string) {
      // If it's now presentationMode, then this function shouldn't be called.
      // If this function is called, then it might be in the case that
      //   1. Using singlePreview
      //   2. Switch from a presentationMode file to not presentationMode file.
      if (this.presentationMode) {
        this.postMessage("refreshPreview", [this.sourceUri]);
      }

      // editorScrollDelay = Date.now() + 500
      this.previewScrollDelay = Date.now() + 500;

      this.hiddenPreviewElement.innerHTML = html;

      const scrollTop = this.previewElement.scrollTop;
      // init several events
      this.initEvents().then(() => {
        this.scrollMap = null;

        this.bindTagAClickEvent();
        this.bindTaskListEvent();

        // set id and classes
        this.previewElement.id = id || "";
        this.previewElement.setAttribute(
          "class",
          `mume markdown-preview ${classes}`,
        );

        // scroll to initial position
        if (!this.doneLoadingPreview) {
          this.doneLoadingPreview = true;
          this.scrollToRevealSourceLine(this.initialLine);

          // clear @scrollMap after 2 seconds because sometimes
          // loading images will change scrollHeight.
          setTimeout(() => (this.scrollMap = null), 2000);
        } else {
          // restore scrollTop
          this.previewElement.scrollTop = scrollTop; // <= This line is necessary...
        }
      });
    }

    /**
     * Build offsets for each line (lines can be wrapped)
     * That's a bit dirty to process each line everytime, but ok for demo.
     * Optimizations are required only for big texts.
     * @return number[]
     */
    private buildScrollMap(): number[] {
      if (!this.totalLineCount) {
        return null;
      }
      const scrollMap = [];
      const nonEmptyList = [];

      for (let i = 0; i < this.totalLineCount; i++) {
        scrollMap.push(-1);
      }

      nonEmptyList.push(0);
      scrollMap[0] = 0;

      // write down the offsetTop of element that has 'data-line' property to scrollMap
      const lineElements = this.previewElement.getElementsByClassName(
        "sync-line",
      );

      for (let i = 0; i < lineElements.length; i++) {
        let el = lineElements[i] as HTMLElement;
        let t: any = el.getAttribute("data-line");
        if (!t) {
          continue;
        }

        t = parseInt(t, 10);
        if (!t) {
          continue;
        }

        // this is for ignoring footnote scroll match
        if (t < nonEmptyList[nonEmptyList.length - 1]) {
          el.removeAttribute("data-line");
        } else {
          nonEmptyList.push(t);

          let offsetTop = 0;
          while (el && el !== this.previewElement) {
            offsetTop += el.offsetTop;
            el = el.offsetParent as HTMLElement;
          }

          scrollMap[t] = Math.round(offsetTop);
        }
      }

      nonEmptyList.push(this.totalLineCount);
      scrollMap.push(this.previewElement.scrollHeight);

      let pos = 0;
      for (let i = 0; i < this.totalLineCount; i++) {
        if (scrollMap[i] !== -1) {
          pos++;
          continue;
        }

        const a = nonEmptyList[pos - 1];
        const b = nonEmptyList[pos];
        scrollMap[i] = Math.round(
          (scrollMap[b] * (i - a) + scrollMap[a] * (b - i)) / (b - a),
        );
      }

      return scrollMap; // scrollMap's length == screenLineCount (vscode can't get screenLineCount... sad)
    }

    private scrollEvent() {
      if (!this.config.scrollSync) {
        return;
      }

      if (!this.scrollMap) {
        this.scrollMap = this.buildScrollMap();
        return;
      }

      if (Date.now() < this.previewScrollDelay) {
        return;
      }
      this.previewSyncSource();
    }

    private previewSyncSource() {
      let scrollToLine;

      if (this.previewElement.scrollTop === 0) {
        // editorScrollDelay = Date.now() + 100
        scrollToLine = 0;

        this.postMessage("revealLine", [this.sourceUri, scrollToLine]);
        return;
      }

      const top =
        this.previewElement.scrollTop + this.previewElement.offsetHeight / 2;

      // try to find corresponding screen buffer row
      if (!this.scrollMap) {
        this.scrollMap = this.buildScrollMap();
      }

      let i = 0;
      let j = this.scrollMap.length - 1;
      let count = 0;
      let screenRow = -1; // the screenRow is the bufferRow in vscode.
      let mid;

      while (count < 20) {
        if (Math.abs(top - this.scrollMap[i]) < 20) {
          screenRow = i;
          break;
        } else if (Math.abs(top - this.scrollMap[j]) < 20) {
          screenRow = j;
          break;
        } else {
          mid = Math.floor((i + j) / 2);
          if (top > this.scrollMap[mid]) {
            i = mid;
          } else {
            j = mid;
          }
        }
        count++;
      }

      if (screenRow === -1) {
        screenRow = mid;
      }

      scrollToLine = screenRow;

      this.postMessage("revealLine", [this.sourceUri, scrollToLine]);
      // @scrollToPos(screenRow * @editor.getLineHeightInPixels() - @previewElement.offsetHeight / 2, @editor.getElement())
      // # @editor.getElement().setScrollTop

      // track currnet time to disable onDidChangeScrollTop
      // editorScrollDelay = Date.now() + 100
    }

    /**
     * Analyze slides and generate `this.slidesData`
     */
    private initSlidesData() {
      const slideElements = document.getElementsByTagName("section");
      let offset = 0;
      for (let i = 0; i < slideElements.length; i++) {
        const slide = slideElements[i];
        if (slide.hasAttribute("data-line")) {
          const line = parseInt(slide.getAttribute("data-line"), 10);
          const h = parseInt(slide.getAttribute("data-h"), 10);
          const v = parseInt(slide.getAttribute("data-v"), 10);
          this.slidesData.push({ line, h, v, offset });
          offset += 1;
        }
      }
    }

    /**
     * scroll sync to display slide according `line`
     * @param: line: the buffer row of editor
     */
    private scrollSyncToSlide(line: number) {
      for (let i = this.slidesData.length - 1; i >= 0; i--) {
        if (line >= this.slidesData[i].line) {
          const { h, v, offset } = this.slidesData[i];
          if (offset === this.currentSlideOffset) {
            return;
          }

          this.currentSlideOffset = offset;
          window["Reveal"].slide(h, v);
          break;
        }
      }
    }

    /**
     * scroll preview to match `line`
     * @param line: the buffer row of editor
     */
    private scrollSyncToLine(line: number, topRatio: number = 0.372) {
      if (!this.scrollMap) {
        this.scrollMap = this.buildScrollMap();
      }
      if (!this.scrollMap || line >= this.scrollMap.length) {
        return;
      }

      if (line + 1 === this.totalLineCount) {
        // last line
        this.scrollToPos(this.previewElement.scrollHeight);
      } else {
        /**
         * Since I am not able to access the viewport of the editor
         * I used `golden section` (0.372) here for scrollTop.
         */
        this.scrollToPos(
          Math.max(
            this.scrollMap[line] - this.previewElement.offsetHeight * topRatio,
            0,
          ),
        );
      }
    }

    /**
     * Smoothly scroll the previewElement to `scrollTop` position.
     * @param scrollTop: the scrollTop position that the previewElement should be at
     */
    private scrollToPos(scrollTop) {
      if (this.scrollTimeout) {
        clearTimeout(this.scrollTimeout);
        this.scrollTimeout = null;
      }

      if (scrollTop < 0) {
        return;
      }

      const delay = 10;

      const helper = (duration = 0) => {
        this.scrollTimeout = setTimeout(() => {
          if (duration <= 0) {
            this.previewScrollDelay = Date.now() + 500;
            this.previewElement.scrollTop = scrollTop;
            return;
          }

          const difference = scrollTop - this.previewElement.scrollTop;

          const perTick = (difference / duration) * delay;

          // disable preview onscroll
          this.previewScrollDelay = Date.now() + 500;

          this.previewElement.scrollTop += perTick;
          if (this.previewElement.scrollTop === scrollTop) {
            return;
          }

          helper(duration - delay);
        }, delay);
      };

      const scrollDuration = 120;
      helper(scrollDuration);
    }

    /**
     * It's unfortunate that I am not able to access the viewport.
     * @param line
     */
    private scrollToRevealSourceLine(line, topRatio = 0.372) {
      if (line === this.currentLine) {
        return;
      } else {
        this.currentLine = line;
      }

      // disable preview onscroll
      this.previewScrollDelay = Date.now() + 500;

      if (this.presentationMode) {
        this.scrollSyncToSlide(line);
      } else {
        this.scrollSyncToLine(line, topRatio);
      }
    }

    /**
     * [esc] is pressed.
     */
    private escPressed(event = null) {
      if (event) {
        event.preventDefault();
        event.stopPropagation();
      }
      if (this.config.vscode) {
        if (!this.presentationMode) {
          this.toolbar.sidebarTOCBtn.click();
        }
      } else {
        if (window["$"]("#image-helper-view").is(":visible")) {
          // close image helper
          $["modal"].close();
        } else {
          this.toolbar.sidebarTOCBtn.click();
        }
      }
    }

    /**
     * Initialize several `window` events.
     */
    private initWindowEvents() {
      /**
       * Several keyboard events.
       */
      window.addEventListener("keydown", (event) => {
        if (event.shiftKey && event.ctrlKey && event.which === 83) {
          // ctrl+shift+s preview sync source
          return this.previewSyncSource();
        } else if (event.metaKey || event.ctrlKey) {
          // ctrl+c copy
          if (event.which === 67) {
            // [c] copy
            document.execCommand("copy");
          } else if (event.which === 187 && !this.config.vscode) {
            // [+] zoom in
            this.zoomIn();
          } else if (event.which === 189 && !this.config.vscode) {
            // [-] zoom out
            this.zoomOut();
          } else if (event.which === 48 && !this.config.vscode) {
            // [0] reset zoom
            this.resetZoom();
          } else if (event.which === 38) {
            // [ArrowUp] scroll to the most top
            if (this.presentationMode) {
              window["Reveal"].slide(0);
            } else {
              this.previewElement.scrollTop = 0;
            }
          }
        } else if (event.which === 27) {
          // [esc] toggle sidebar toc
          this.escPressed(event);
        }
      });

      window.addEventListener("resize", () => {
        this.scrollMap = null;
      });

      window.addEventListener(
        "message",
        (event) => {
          const data = event.data;
          if (!data) {
            return;
          }

          // console.log('receive message: ' + data.command)

          if (data.command === "updateHTML") {
            this.totalLineCount = data.totalLineCount;
            this.sidebarTOCHTML = data.tocHTML;
            this.sourceUri = data.sourceUri;
            this.renderSidebarTOC();
            this.updateHTML(data.html, data.id, data.class);
          } else if (
            data.command === "changeTextEditorSelection" &&
            (this.config.scrollSync || data.forced)
          ) {
            const line = parseInt(data.line, 10);
            let topRatio = parseFloat(data.topRatio);
            if (isNaN(topRatio)) {
              topRatio = 0.372;
            }
            this.scrollToRevealSourceLine(line, topRatio);
          } else if (data.command === "startParsingMarkdown") {
            /**
             * show refreshingIcon after 1 second
             * if preview hasn't finished rendering.
             */
            if (this.refreshingIconTimeout) {
              clearTimeout(this.refreshingIconTimeout);
            }

            this.refreshingIconTimeout = setTimeout(() => {
              if (!this.presentationMode) {
                this.refreshingIcon.style.display = "block";
              }
            }, 1000);
          } else if (data.command === "openImageHelper") {
            window["$"]("#image-helper-view").modal();
          } else if (data.command === "runAllCodeChunks") {
            this.runAllCodeChunks();
          } else if (data.command === "runCodeChunk") {
            this.runNearestCodeChunk();
          } else if (data.command === "escPressed") {
            this.escPressed();
          } else if (data.command === "previewSyncSource") {
            this.previewSyncSource();
          } else if (data.command === "copy") {
            document.execCommand("copy");
          } else if (data.command === "zommIn") {
            this.zoomIn();
          } else if (data.command === "zoomOut") {
            this.zoomOut();
          } else if (data.command === "resetZoom") {
            this.resetZoom();
          } else if (data.command === "scrollPreviewToTop") {
            if (this.presentationMode) {
              window["Reveal"].slide(0);
            } else {
              this.previewElement.scrollTop = 0;
            }
          }
        },
        false,
      );
    }

    /* End of PreviewController class */
  }

  function onLoad() {
    /* tslint:disable-next-line:no-unused-expression */
    new PreviewController();
  }

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", onLoad);
  } else {
    onLoad();
  }
})();
